socket函数

1
2
#include <sys/socket.h>
int socket(int family, int type, int protocol);

socket函数在成功时返回一个小的非负整数值,称为套接字描述符,简称sockfd。

connect函数

1
2
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr* servaddr, socklen_t addrlen);

sockfd是socket函数返回的套接字描述符,第二个、第三个参数分别是一个指向套接字地址结构的指针和该结构的大小,套接字地址结构必须含有服务器的IP地址和端口号。

客户在调用函数connect前不必非得调用bind函数,如果需要的话内核会确定源IP地址,并选择一个临时端口作为源端口。

如果是TCP套接字,调用connect函数将会激发TCP的三路握手过程,connect出错返回可能有以下几种情况:

  • 若TCP客户没有收到SYN分节的响应,则返回ETIMEDOUT错误,举例来说,调用connect函数时,内核发送一个SYN,若无响应则等待6S后再发送一个,若仍无响应则等待24S后再发送一个。若总共等了75S后仍未收到响应则返回本错误。
  • 若对客户的SYN的响应是RST(表示复位),则表明服务器主机在我们指定的端口上没有进程在等待与之连接(例如服务器进程也许没在运行),客户一接收到RST就马上返回ECONNREFUSED错误。

    RST是TCP在发生错误时发送的一种TCP分节,产生RST的三个条件是:目的地为某端口的SYN到达,然而该端口上没有正在监听的服务器;TCP想取消一个已有连接;TCP接收到一个根本不存在的连接上的分节。

  • 若客户发出的SYN在中间的某个路由器上引发了一个”destination unreachable”的ICMP错误,则认为是一种软错误,客户主机内核保存该消息,并按第一种情况中所述的时间间隔继续发送SYN,若在某个规定的时间后仍未收到响应,则把保存的消息(ICMP消息)作为EHOSTUNREACH或ENETUNREACH错误返回给进程。

connect函数导致当前套接字从CLOSED状态(该套接字自从由socket函数创建以来一直所处的状态)转移到SYN_SENT状态,若成功则再转移到ESTABLISHED状态,若connect失败则该套接字不再可用,必须关闭,不能对这样的套接字再次调用connect函数。

bind函数

bind函数是把一个本地协议地址赋予一个套接字,对于网际协议,协议地址是32位的IPV4地址或128位的IPV6地址与16位的TCP或UDP端口号的组合。

1
2
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr* myaddr, socklen_t addrlen);

第二个参数是一个指向特定于协议的地址结构的指针,第三个参数是该地址结构的长度,对于TCP, 调用bind函数可以指定一个端口号,或指定一个IP地址,也可以两者都指定,还可以都不指定。

  • 服务器在启动时捆绑它们的众所周知端口,如果一个TCP客户或服务器未曾调用bind捆绑一个端口,当调用connect或listen时,内核就要为相应的套接字选择一个临时端口,让内核来选择临时端口对于TCP客户来说是正常的,除非应用需要一个预留端口;然而对于TCP服务器来说却极为罕见,因为服务器是通过它们的众所周知端口被大家认识的。
  • 进程可以把一个特定的IP地址捆绑到它的套接字上,不过这个IP地址必须属于其所在主机的网络接口之一。对于TCP客户,这就为在该套接字上发送的IP数据报指派了源IP地址,而对于TCP服务器,这就限定了该套接字只接收那些目的地为这个IP地址的客户连接。
  • 如果指定端口号为0,那么内核在bind被调用时选择一临时端口,然而如果指定IP地址为通配地址,那么内核将等到套接字已连接(TCP)或已在套接字上发出数据报(UDP)时才选择一个本地IP地址。
  • 如果让内核来为套接字选择一个临时端口号,需要注意的是函数bind并不返回所选择的值,为了得到内核所选择的这个临时端口值,必须调用函数getsockname来返回协议地址。

listen函数

listen函数仅有TCP服务器调用:

  • 当socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect发起连接的客服套接字,listen函数把一个未连接的套接字转换成一个被动套接字,指示内核应接受指向该套接字的连接请求,调用listen函数将导致套接字从CLOSED状态转换到LISTEN状态。

    • 本函数的第二个参数规定了内核应该为相应套接字排队的最大连接个数:

      1
      2
      #include <sys/socket.h>
      int listen(int sockfd, int backlog);

      本函数通常应该在调用socket和bind这两个函数之后,并在调用accept函数之前调用。

      内核为任何一个给定的监听套接字维护两个队列:

      • 未完成连接队列,每个这样的SYN分节对应其中一项:已有某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程。这些套接字处于SYN_RCVD状态
      • 已完成连接队列,每个已完成TCP三路握手过程的客户对应其中一项,这些套接字处于ESTABLISHED状态。

      每当在未完成连接队列中创建一项时,来自监听套接字的参数就复制到即将建立的连接中,连接的创建机制是完全自动的,无需服务器进程插手。

      当来自客户的SYN到达时,TCP在未完成队列中创建了一个新项,然后响应以三路握手的第二个分节:服务器的SYN响应,其中捎带对客户SYN的ACK,这一项一直保存在未完成连接队列中,直到三路握手的第三个分节(客户对服务器SYN的ACK)到达或者该项超时为止。如果三路握手正常完成,该项就从未完成连接队列移到已完成连接队列的队尾。当进程调用accept时,已完成连接队列的队头项将返回给进程,或者如果该队列为空,那么进程将被投入睡眠,指导TCP在该队列中放入一项才唤醒它。

accept函数

accept函数由TCP服务器调用,用于从已完成连接队列队头返回下一个已完成连接,如果已完成连接队列为空,那么进程被投入睡眠。

1
2
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr* cliaddr, socklen_t* addrlen);

其中参数cliaddr和addrlen用来返回已连接的对端进程(客户)的协议地址。如果accept成功,那么其返回值是由内核自动生成的一个全新描述符,代表与所返回客户的TCP连接。

需要注意的是和accept函数的套接字描述符有两个,分别是监听套接字和已连接套接字,其中监听套接字由socket函数创建,随后用作bind和linten的第一个参数的描述符;另一个套接字为已连接套接字,为accept函数的返回值。

一个服务器通常仅仅创建一个监听套接字,它在该服务器的生命期内一直存在,内核为每个由服务器进程接受的客户连接创建一个已连接套接字,当服务器完成对某个给定客户的服务时,相应的已连接套接字就被关闭。

fork和exec函数

1
2
#include <unistd.h>
pid_t fork(void);

fork()函数调用一次,但是却返回两次,它在调用进程(父进程)中返回一次,返回值是新派生进程(子进程)的进程ID号,在子进程中又返回一次,返回值是0。

父进程中调用fork之前打开的所有描述符在fork返回之后由子进程共享。父进程调用accept之后调用fork,所接受的已连接套接字随后就在父进程和子进程之间共享。通常情况下,子进程接着读写这个已连接套接字,父进程则关闭这个已连接套接字。

关于文件描述符的继承

通过fork创建子进程时,子进程继承父进程环境和上下文的大部分内容的拷贝,其中就包括文件描述符表。

对于父进程在fork之前打开的文件来说,子进程都会继承,与父进程共享相同的文件偏移量。系统文件表位于系统空间中,不会被fork复制,但是系统文件表中的条目会保存指向它的文件描述符表的计数,fork时需要对这个计数进行维护,以体现子进程子进程对应的新的文件描述符表也指向它。程序关闭文件时,也就是将系统文件表条目内部的计数减一,当计数值减为0时,才将其删除。

并发服务器

Unix中编写并发服务器程序最简单的办法就是fork一个子进程来服务每个客户。举个例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pid_t pid;
int listenfd, connfd;
listenfd = Socket(...);
Bind(listenfd, ...);
for (; :) {
connfd = Accept(listenfd, ...);
if ((pid = Fork()) == 0) {
Close(listenfd); // 子进程关闭监听socket
doit(connfd); // 处理请求
Close(connfd); // 关闭连接socket
exit(0); // 子进程退出
}
Close(connfd); // 父进程关闭连接socket
}

对于上面的程序,也许你会感到困惑,明明父进程中调用了close函数关闭了connfd,为什么在子进程中还可以使用connfd。通过前面的学习我们知道当对一个TCP套接字调用close函数后会导致发送一个FIN,随后是正常的TCP连接终止序列,那么为什么上面的程序中父进程对connfd调用close没有终止它与客户的连接呢?

其实每个文件或套接字都有一个引用计数,引用计数在文件表项中维护,它是当前打开着的引用该文件或套接字的描述符的个数。在上面的程序中,socket返回后与listenfd关联的文件表项的引用计数值为1,accept返回后与connfd关联的文件表项的引用计数值也为1。然而fork返回后,这两个描述符就在父进程和子进程之间共享(被复制),因此与这两个套接字相关联的文件表项各自的访问计数值均为2,这么一来,当父进程关闭connfd后,它只是把响应的引用计数值从2减为1,该套接字真正的清理和资源释放要等到起引用计数值到达0时才发生。

close函数

close函数用来关闭套接字,并终止TCP连接,其函数原型如下:

1
2
#include <unistd.h>
int close(int sockfd);

close一个TCP套接字的默认行为是把该套接字标记为已关闭,然后立即返回到调用进程,该套接字描述符不能再由调用进程使用,然而TCP将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP连接终止序列。

在前面我们说过,并发服务器中父进程关闭已连接套接字只是导致相应描述符的引用计数值减1,既然引用计数值仍大于0,这个close调用并不引发TCP的四分组连接终止序列。

getsockname和getpeername函数

这两个函数或者返回与某个套接字关联的本地协议地址,或者返回与某个套接字关联的外地协议地址。

1
2
3
#include <sys/socket.h>
int getsockname(int sockfd, struct sockaddr* loacladdr, socklen_t* addrlen);
int getpeername(int sockfd, struct sockaddr* peeraddr, socklen_t* addrlen);
  • 在一个没有调用bind的TCP客户上,connect成功返回后,getsockname用于返回由内核赋予该链接的本地IP和本地端口号
  • 在以端口号0调用bind后,getsockname用于返回由内核赋予的本地端口号
  • getsockname可用于获取某个套接字的地址族
  • 在一个以通配IP地址调用bind的服务器上,与某个客户的连接一旦建立(accept成功返回),getsockname就可以用于返回由内核赋予该链接的本地IP地址,在这样的调用中,套接字描述符参数必须是已连接套接字的描述符,而不是监听套接字的描述符